Skip to content

Java 异常处理机制

Java 异常处理机制 — 完整技术笔记


一、概述

Java 异常处理机制是 Java 语言通过 Throwable 类体系,将程序正常业务逻辑与错误处理代码分离的一套标准化方案。它解决的核心问题是:在运行时遇到可预见或不可预见的错误条件时,如何优雅地中断当前流程、传递错误信息,并在合适的层次进行恢复或终止

完善的异常处理是编写工业级代码的基本要求。反之,异常的不当使用(吞异常、过度捕获、丢失异常链)是线上问题难以排查的最常见根源之一。


二、核心概念 / 原理:异常体系结构

2.1 体系全景

Java 异常体系以 Throwable 为根,分为两大分支:

mermaid
classDiagram
    Throwable <|-- Error
    Throwable <|-- Exception
    Exception <|-- IOException
    Exception <|-- SQLException
    Exception <|-- RuntimeException
    RuntimeException <|-- NullPointerException
    RuntimeException <|-- IllegalArgumentException
    RuntimeException <|-- ClassCastException
    IllegalArgumentException <|-- NumberFormatException
    Error <|-- OutOfMemoryError
    Error <|-- StackOverflowError
    Error <|-- NoClassDefFoundError

    class Throwable {
        +String getMessage()
        +String getLocalizedMessage()
        +Throwable getCause()
        +void printStackTrace()
        +Throwable[] getSuppressed()
    }
    class Error {
        <<不可恢复 - 系统级>>
    }
    class Exception {
        <<可恢复 - 应用级>>
    }
    class RuntimeException {
        <<Unchecked 非受检>>
    }

2.2 Error 与 Exception 的本质区别

维度ErrorException
性质JVM / 系统级严重错误应用程序可处理的异常情况
可恢复性通常不可恢复可恢复,应针对性处理
是否应捕获一般不应捕获(最外层打日志后快速失败)应捕获并处理
典型代表OutOfMemoryErrorStackOverflowErrorNoClassDefFoundErrorIOExceptionNullPointerExceptionSQLException

注意StackOverflowError 通常由无限递归引起;OutOfMemoryError 表示堆或元空间耗尽。应用程序顶多在最外层 catch (Throwable t) 打印日志后立即终止,不应试图从 Error 中恢复。

2.3 受检异常 vs 非受检异常

特性Checked ExceptionUnchecked Exception
定义Exception 的子类(排除 RuntimeExceptionRuntimeException 及其子类
编译器检查✅ 强制要求 try-catchthrows❌ 编译器不强制
设计意图提醒调用方处理可预见的异常通常是编程错误,应通过代码逻辑避免
典型代表IOExceptionSQLExceptionClassNotFoundExceptionNullPointerExceptionIllegalArgumentException
现代趋势Spring 等框架倾向包装为 RuntimeException 抛出优先使用,减少样板代码

常见 RuntimeException 速查表

异常类触发场景
NullPointerExceptionnull 引用调用方法或访问属性
ArrayIndexOutOfBoundsException数组索引超出范围
ClassCastException不兼容类型的强制转换
NumberFormatException字符串无法转换为数字(IllegalArgumentException 子类)
IllegalArgumentException方法接收到非法参数
IllegalStateException方法在不合法的状态下被调用
UnsupportedOperationException调用了不支持的操作(如修改不可变集合)
ArithmeticException算术异常(如除以零)

2.4 异常的传播机制

异常从抛出点沿调用栈向上逐层传播,直到被 catch 块捕获,或到达 main 方法导致线程终止。

mermaid
sequenceDiagram
    participant main
    participant methodA
    participant methodB
    participant methodC

    main->>methodA: 调用
    methodA->>methodB: 调用
    methodB->>methodC: 调用
    methodC->>methodC: throw new IOException()
    methodC-->>methodB: 异常向上传播
    methodB-->>methodA: 未捕获,继续传播
    methodA->>methodA: catch(IOException e) 捕获处理
    methodA-->>main: 正常返回

关键点

  • 每个异常对象在创建时会记录当前线程的调用栈快照(StackTrace),这是排查问题的核心信息
  • 未被捕获的异常最终由线程的 UncaughtExceptionHandler 处理(可设置自定义 Handler 用于监控告警)
java
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
    log.error("线程 {} 发生未捕获异常", thread.getName(), throwable);
    // 发送告警通知
});

2.5 Throwable 类常用方法

方法说明
String getMessage()返回异常的详细描述信息
String toString()返回异常的简要描述(类名 + message)
String getLocalizedMessage()返回本地化信息,子类可覆盖实现国际化
void printStackTrace()在控制台打印完整的异常栈信息
Throwable getCause()获取原始异常(异常链)
Throwable[] getSuppressed()获取被抑制的异常数组(Java 7+)

三、关键知识点详解

3.1 try-catch-finally

java
try {
    // 可能抛出异常的代码
    int result = 10 / 0;
} catch (ArithmeticException e) {
    // 捕获特定异常并处理
    System.out.println("算术异常: " + e.getMessage());
} catch (Exception e) {
    // 更宽泛的异常兜底(子类 catch 必须在父类之前)
    System.out.println("通用异常: " + e.getMessage());
} finally {
    // 无论是否异常都执行,用于资源清理
    System.out.println("Finally 执行");
}

Java 7 多异常合并语法

java
catch (IOException | SQLException e) {
    log.error("IO或SQL异常", e);
}

注意:多异常合并时,变量 e 的类型是这些异常的共同父类,且 e 是隐式 final 的,不可重新赋值。

3.1.1 finally 的执行保证与陷阱

finally 不执行的极端情况

  • System.exit() 导致 JVM 进程终止
  • 线程被强制 kill
  • JVM 崩溃(如 OutOfMemoryError 导致进程直接终止)

⚠️ return 陷阱(高频面试考点)

java
public int getInt() {
    int i = 0;
    try {
        i = 1;
        return i;  // try 中的 return
    } finally {
        i = 2;
        return i;  // finally 中的 return,覆盖了 try 的返回值
    }
}
// 返回值:2
java
public int getInt2() {
    int i = 0;
    try {
        i = 1;
        return i;  // 返回值在执行 finally 前已被"快照"
    } finally {
        i = 2;     // 修改了 i,但不影响已快照的返回值
    }
}
// 返回值:1

字节码层面解释

getInt() 的字节码关键流程:

0  iconst_0        // 压入 0
1  istore_1        // i = 0
2  iconst_1        // 压入 1
3  istore_1        // i = 1
4  iload_1         // 加载 i(值为 1)
5  istore_2        // 快照返回值到临时变量
6  iconst_2        // 压入 2
7  istore_1        // i = 2(finally 修改了 i)
8  iload_1         // 加载 i(值为 2)
9  ireturn         // 返回 2 ← finally 的 return 覆盖了 try 的 return

核心原理:编译器在字节码层面将 finally 块的代码复制展开到每一条可能的退出路径(正常 return + 每个 catch 路径 + 异常退出路径)之后。如果 finally 中有 return,其指令在 tryreturn 之后执行,从而覆盖返回值。可通过 javap -c 清晰看到这种代码复制结构。

🔑 铁律绝对不要在 finally 中使用 returnthrow,否则会覆盖 try/catch 中的返回值或抑制原始异常。

异常抑制问题

java
try {
    throw new IOException("原始异常");
} finally {
    throw new RuntimeException("finally中的异常");
    // 原始 IOException 被"抑制",无法被外层感知!
}

3.2 try-with-resources(Java 7+)

对实现了 AutoCloseable 接口的资源,try-with-resources 自动在 try 块结束后调用 close(),无需 finally 手动关闭。

java
try (FileInputStream in = new FileInputStream("test.txt");
     BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
    String line = reader.readLine();
    System.out.println(line);
} catch (IOException e) {
    e.printStackTrace();
}
// 资源按声明的逆序自动关闭:先关 reader,再关 in

异常抑制机制:当 try 块和 close() 同时抛出异常时,close() 的异常会被抑制(Suppressed),附加到原始异常上,原始异常信息不丢失:

java
try (MyResource resource = new MyResource()) {
    throw new IOException("业务异常");
} catch (IOException e) {
    System.out.println("主异常: " + e.getMessage());
    for (Throwable suppressed : e.getSuppressed()) {
        System.out.println("被抑制的异常: " + suppressed.getMessage());
    }
}

反编译后的本质:编译器将 try-with-resources 转换为嵌套的 try-catch-finally 结构,并通过 addSuppressed() 保留被抑制的异常。

3.2.1 AutoCloseable 与 Closeable 的区别

特性AutoCloseableCloseable
所在包java.langjava.io
继承关系根接口继承自 AutoCloseable
close() 异常可抛任意 Exception只能抛 IOException
适用场景通用资源(数据库连接、锁等)IO 相关资源
幂等性要求close() 应设计为幂等(多次调用安全)同左

3.3 throw 与 throws

比较维度throwthrows
位置方法体内部方法签名声明处
作用实际抛出一个异常对象声明方法可能抛出的异常类型
后跟内容异常实例:throw new XxxException()异常类型:throws IOException, SQLException
执行效果立即中断当前方法,控制权转移到调用栈上层仅为静态声明,不会直接触发异常
使用约束受检异常 throw 后必须配合 catchesthrows受检异常必须声明;RuntimeException 可选
java
public void transfer(BigDecimal amount) throws InsufficientBalanceException {
    if (amount.compareTo(BigDecimal.ZERO) <= 0) {
        throw new IllegalArgumentException("转账金额必须大于0");
    }
    if (balance.compareTo(amount) < 0) {
        throw new InsufficientBalanceException("余额不足", balance, amount);
    }
    // 正常转账逻辑...
}

3.4 受检异常在 Lambda 中的处理

函数式接口(如 Function<T, R>Consumer<T>)的抽象方法未声明 throws,导致 Lambda 不能直接抛出受检异常。

方案一:内部 catch 包装为 RuntimeException

java
list.forEach(item -> {
    try {
        processWithIO(item);
    } catch (IOException e) {
        throw new UncheckedIOException(e);  // 包装为非受检异常
    }
});

方案二:自定义能抛受检异常的函数式接口

java
@FunctionalInterface
public interface ThrowingConsumer<T, E extends Exception> {
    void accept(T t) throws E;
}

public static <T> Consumer<T> wrap(ThrowingConsumer<T, Exception> consumer) {
    return t -> {
        try {
            consumer.accept(t);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    };
}

// 使用
list.forEach(wrap(item -> processWithIO(item)));

方案三:Lombok @SneakyThrows

java
list.forEach(item -> processItem(item));

@SneakyThrows
private void processItem(String item) {
    // 可以直接抛出受检异常,Lombok 通过字节码操作绕过编译器检查
    Files.readString(Path.of(item));
}

注意@SneakyThrows 本质是利用泛型擦除的技巧在字节码层面绕过检查,需谨慎使用,避免调用方完全无法感知受检异常的存在。


四、自定义异常设计

4.1 为什么要自定义异常

业务系统需要用异常表达领域语义,如 OrderNotFoundExceptionInsufficientStockException。Java 内置异常无法精确描述业务含义,也无法携带错误码等业务信息。

4.2 推荐的异常层次设计

mermaid
classDiagram
    RuntimeException <|-- BaseException
    BaseException <|-- BusinessException
    BaseException <|-- SystemException
    BusinessException <|-- OrderException
    BusinessException <|-- UserException
    OrderException <|-- OrderNotFoundException
    OrderException <|-- InsufficientStockException

    class BaseException {
        -ErrorCode errorCode
        -String message
        +BaseException(ErrorCode, String)
        +BaseException(ErrorCode, String, Throwable)
    }
    class ErrorCode {
        <<enumeration>>
        +String code
        +String description
        +ErrorLevel level
    }

4.3 实战代码示例

错误码枚举

java
public enum ErrorCode {
    SUCCESS("0", "成功", ErrorLevel.INFO),
    PARAM_ERROR("400", "请求参数错误", ErrorLevel.INFO),
    UNAUTHORIZED("403", "无权限", ErrorLevel.WARN),
    NOT_FOUND("601", "数据不存在", ErrorLevel.ERROR),
    SYSTEM_ERROR("500", "系统异常", ErrorLevel.ERROR),
    RPC_ERROR("502", "远程调用异常", ErrorLevel.ERROR);

    private final String code;
    private final String description;
    private final ErrorLevel level;

    ErrorCode(String code, String description, ErrorLevel level) {
        this.code = code;
        this.description = description;
        this.level = level;
    }
    // getter...
}

基础异常类

java
public class BaseException extends RuntimeException {
    private final ErrorCode errorCode;

    public BaseException(ErrorCode errorCode) {
        super(errorCode.getDescription());
        this.errorCode = errorCode;
    }

    public BaseException(ErrorCode errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    // ⚠️ 关键:保留原始异常链
    public BaseException(ErrorCode errorCode, String message, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}

具体业务异常

java
public class OrderNotFoundException extends BaseException {
    public OrderNotFoundException(String orderId) {
        super(ErrorCode.NOT_FOUND, "订单不存在: " + orderId);
    }
}

4.4 异常链(Cause)的重要性

❌ 错误做法 — 吞掉原始异常

java
try {
    orderRepository.findById(id);
} catch (DataAccessException e) {
    // 原始异常的 StackTrace 丢失!排查问题时无法定位根因
    throw new OrderNotFoundException("查询订单失败");
}

✅ 正确做法 — 保留异常链

java
try {
    orderRepository.findById(id);
} catch (DataAccessException e) {
    // 原始异常作为 cause 传入,完整保留调用链
    throw new BaseException(ErrorCode.SYSTEM_ERROR, "查询订单失败", e);
}

通过 getCause() 可以递归获取原始异常,printStackTrace() 会打印完整的 cause 链:

com.example.BaseException: 查询订单失败
    at com.example.OrderService.findOrder(OrderService.java:42)
    ...
Caused by: org.springframework.dao.DataAccessException: Connection refused
    at org.springframework.jdbc...
    ...
Caused by: java.net.ConnectException: Connection refused
    at java.base/java.net.Socket.connect(Socket.java:633)
    ...

4.5 全局异常处理(Spring Boot)

java
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BaseException.class)
    public ResponseEntity<ErrorResponse> handleBaseException(BaseException e) {
        ErrorCode code = e.getErrorCode();
        switch (code.getLevel()) {
            case INFO -> log.info("业务提示: {}", e.getMessage());
            case WARN -> log.warn("业务警告: {}", e.getMessage());
            case ERROR -> log.error("业务异常: {}", e.getMessage(), e);
        }
        return ResponseEntity.badRequest()
                .body(new ErrorResponse(code.getCode(), e.getMessage()));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
        log.error("系统未知异常", e);
        return ResponseEntity.internalServerError()
                .body(new ErrorResponse("500", "系统异常,请联系管理员"));
    }
}

五、最佳实践 / 常见坑

5.1 异常处理最佳实践总结

#实践原则说明
1不要吞异常catch 后必须处理(记录日志 / 抛出 / 恢复),绝不留空 catch
2不要用异常控制流程异常创建涉及填充栈帧,比条件判断慢 100 倍以上
3精确捕获异常类型避免 catch(Exception e) 大包大揽,区分业务异常与系统异常
4日志记录包含完整异常log.error("msg", e) 而非 log.error(e.getMessage())
5在 API 边界转换异常技术异常 → 业务异常,不向外暴露内部实现细节
6不在 finally 中 return/throw会抑制原始异常或覆盖返回值
7使用 try-with-resources所有 AutoCloseable 资源必须使用此语法
8保留异常链throw new XxxException("msg", cause) 不要丢弃 cause
9尽可能晚地捕获异常让异常传播到最合适的层次统一处理
10不要重复记录同一异常避免每层 catch 都打日志,统一交由最上层处理

5.2 常见反模式与修正

反模式 1:吃掉异常

java
// ❌ 异常被静默吞掉,线上出问题完全无法排查
try {
    riskyOperation();
} catch (Exception e) {
    // 什么都没做
}

// ✅ 至少记录日志
try {
    riskyOperation();
} catch (Exception e) {
    log.error("riskyOperation 执行失败", e);
    throw e;
}

反模式 2:日志信息不完整

java
// ❌ 只有 message,丢失了 StackTrace
log.error("处理失败: " + e.getMessage());

// ✅ 传入异常对象,打印完整栈信息
log.error("处理失败, 参数: {}", param, e);

反模式 3:既记日志又抛异常(导致重复打印)

java
// ❌ 下层打一次日志,上层再打一次,同一异常出现两条日志
try {
    service.process();
} catch (Exception e) {
    log.error("处理失败", e);  // 日志 1
    throw e;                   // 上层 catch 又会打日志 2
}

// ✅ 选择其一:打日志或抛异常
// 方案 A:下层只抛,上层统一打
throw new ServiceException("处理失败", e);

// 方案 B:下层打日志并恢复,不再抛出
log.error("处理失败,使用降级方案", e);
return fallbackResult;

反模式 4:异常信息丢失

java
// ❌ 只保留了 message,丢失了原始异常的完整栈
throw new ServiceException(e.getMessage());

// ✅ 保留原始异常作为 cause
throw new ServiceException("服务处理失败", e);

六、对比 / 易混淆点

6.1 finally vs try-with-resources

维度finallytry-with-resources
资源关闭手动在 finally 中调用 close()自动关闭(实现 AutoCloseable
异常抑制finally 异常覆盖 try 异常,原始异常丢失close() 异常被 addSuppressed(),原始异常保留
代码量繁琐,需 null 检查简洁一行声明
关闭顺序需手动控制按声明的逆序自动关闭
推荐度仅用于非资源场景的清理所有资源操作首选

6.2 Checked vs Unchecked 选择指南

mermaid
flowchart TD
    A[调用方能否合理恢复?] -->|能| B[使用 Checked Exception]
    A -->|不能| C[使用 Unchecked Exception]
    B --> D[如: 文件不存在 → FileNotFoundException<br/>调用方可提示用户重新选择]
    C --> E[如: 空指针 → NullPointerException<br/>这是代码 Bug,应修复代码]
    
    style B fill:#e1f5fe
    style C fill:#fff3e0

现代实践:Spring、MyBatis 等主流框架已全面转向 RuntimeException。自定义业务异常建议继承 RuntimeException,减少样板代码,提升函数式编程兼容性。


七、面试高频问题

Q1:Java 异常体系的层次结构是怎样的?Error 和 Exception 有什么区别?

Throwable 是所有异常的根类,分为 ErrorExceptionError 表示 JVM 级别的严重错误(如 OutOfMemoryErrorStackOverflowError),应用程序不应捕获;Exception 表示应用层面可处理的异常,又分为受检异常(编译器强制处理)和非受检异常(RuntimeException,编译器不强制)。

Q2:finally 块一定会执行吗?finally 中 return 会怎样?

:除 System.exit()、线程被 kill、JVM 崩溃外,finally 一定执行。若 tryfinally 都有 return,最终返回 finally 的值——因为在字节码层面,finally 的代码被复制展开到每条退出路径后面,finallyreturn 指令在 tryreturn 之后执行从而覆盖返回值。应绝对避免在 finally 中 return

Q3:try-with-resources 的原理是什么?和 finally 相比有什么优势?

try-with-resources 要求资源实现 AutoCloseable 接口,编译器会将其转换为嵌套的 try-catch-finally 结构,自动调用 close()。核心优势在于:代码简洁、自动关闭、异常抑制机制——当 try 块和 close() 同时抛异常时,close() 的异常通过 addSuppressed() 附加到原始异常上,不会丢失任何异常信息。

Q4:什么时候应该用 Checked Exception,什么时候用 Unchecked Exception?

:当调用方可以且应该对异常进行合理恢复时(如文件不存在让用户重新选择),使用受检异常。当异常代表编程错误(如空指针、参数非法)或调用方无法合理恢复时,使用运行时异常。现代 Java 开发的趋势是优先使用运行时异常,配合全局异常处理器统一处理。

Q5:为什么捕获异常后重新抛出时必须保留异常链?

:异常链通过 Throwable.getCause() 保留了完整的根因信息和调用栈。如果不传入原始异常作为 cause(如 throw new ServiceException("msg")),原始异常的 StackTrace 将完全丢失,线上排查问题时无法定位到真正的错误源头。正确做法是 throw new ServiceException("msg", originalException)


八、总结

┌──────────────────────────────────────────────────────────────┐
│                    Java 异常处理机制要点                        │
├──────────────────────────────────────────────────────────────┤
│  体系结构:Throwable → Error(不捕获) / Exception(需处理)     │
│           Exception → Checked(编译器强制) / Unchecked(不强制) │
├──────────────────────────────────────────────────────────────┤
│  处理语句:try-catch-finally / try-with-resources(首选)       │
│           throw(抛出异常) / throws(声明异常)                 │
├──────────────────────────────────────────────────────────────┤
│  核心陷阱:finally 中不要 return/throw                         │
│           不要吞异常、不要丢失异常链                              │
│           不要同时记日志又抛异常                                  │
├──────────────────────────────────────────────────────────────┤
│  自定义异常:继承 RuntimeException + 错误码枚举 + 保留 cause     │
│  全局处理:@ControllerAdvice + @ExceptionHandler 统一拦截       │
├──────────────────────────────────────────────────────────────┤
│  黄金法则:精确捕获、保留异常链、尽可能晚捕获、统一处理           │
└──────────────────────────────────────────────────────────────┘

一句话记忆:异常处理的本质是在正确的层次、以正确的方式、处理正确类型的异常,同时保留完整的错误上下文信息